diff --git a/assets/src/bundles/add_forge/create-request.js b/assets/src/bundles/add_forge/create-request.js index 3d23afb0..babce755 100644 --- a/assets/src/bundles/add_forge/create-request.js +++ b/assets/src/bundles/add_forge/create-request.js @@ -1,155 +1,128 @@ /** * Copyright (C) 2022 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ -import {handleFetchError, removeUrlFragment, csrfPost, +import {handleFetchError, csrfPost, getHumanReadableDate} from 'utils/functions'; import userRequestsFilterCheckboxFn from 'utils/requests-filter-checkbox.ejs'; import {swhSpinnerSrc} from 'utils/constants'; let requestBrowseTable; const addForgeCheckboxId = 'swh-add-forge-user-filter'; const userRequestsFilterCheckbox = userRequestsFilterCheckboxFn({ 'inputId': addForgeCheckboxId, 'checked': true // by default, display only user requests }); export function onCreateRequestPageLoad() { $(document).ready(() => { $('#requestCreateForm').submit(async function(event) { event.preventDefault(); try { const response = await csrfPost($(this).attr('action'), {'Content-Type': 'application/x-www-form-urlencoded'}, $(this).serialize()); handleFetchError(response); $('#userMessageDetail').empty(); $('#userMessage').text('Your request has been submitted'); $('#userMessage').removeClass('badge-danger'); $('#userMessage').addClass('badge-success'); requestBrowseTable.draw(); // redraw the table to update the list } catch (errorResponse) { $('#userMessageDetail').empty(); let errorMessage; let errorMessageDetail = ''; const errorData = await errorResponse.json(); // if (errorResponse.content_type === 'text/plain') { // does not work? if (errorResponse.status === 409) { errorMessage = errorData; } else { // assuming json response // const exception = errorData['exception']; errorMessage = 'An unknown error occurred during the request creation'; try { const reason = JSON.parse(errorData['reason']); Object.entries(reason).forEach((keys, _) => { const key = keys[0]; const message = keys[1][0]; // take only the first issue errorMessageDetail += `\n${key}: ${message}`; }); } catch (_) { errorMessageDetail = errorData['reason']; // can't parse it, leave it raw } } $('#userMessage').text( errorMessageDetail ? `Error: ${errorMessageDetail}` : errorMessage ); $('#userMessage').removeClass('badge-success'); $('#userMessage').addClass('badge-danger'); } }); - $('#swh-add-forge-requests-list-tab').on('shown.bs.tab', () => { - requestBrowseTable.draw(); - window.location.hash = '#browse-requests'; - }); - - $('#swh-add-forge-requests-help-tab').on('shown.bs.tab', () => { - window.location.hash = '#help'; - }); - - $('#swh-add-forge-tab').on('shown.bs.tab', () => { - removeUrlFragment(); - }); - - $(window).on('hashchange', () => { - onPageHashChage(); - }); - onPageHashChage(); // Explicit call to handle a hash during the page load populateRequestBrowseList(); // Load existing requests }); } -function onPageHashChage() { - if (window.location.hash === '#browse-requests') { - $('.nav-tabs a[href="#swh-add-forge-requests-list"]').tab('show'); - } else if (window.location.hash === '#help') { - $('.nav-tabs a[href="#swh-add-forge-requests-help"]').tab('show'); - } else { - $('.nav-tabs a[href="#swh-add-forge-submit-request"]').tab('show'); - } -} - export function populateRequestBrowseList() { requestBrowseTable = $('#add-forge-request-browse') .on('error.dt', (e, settings, techNote, message) => { $('#add-forge-browse-request-error').text(message); }) .DataTable({ serverSide: true, processing: true, language: { processing: `` }, retrieve: true, searching: true, info: false, // Layout configuration, see [1] for more details // [1] https://datatables.net/reference/option/dom dom: '<"row"<"col-sm-3"l><"col-sm-6 text-left user-requests-filter"><"col-sm-3"f>>' + '<"row"<"col-sm-12"tr>>' + '<"row"<"col-sm-5"i><"col-sm-7"p>>', ajax: { 'url': Urls.add_forge_request_list_datatables(), data: (d) => { if (swh.webapp.isUserLoggedIn() && $(`#${addForgeCheckboxId}`).prop('checked')) { d.user_requests_only = '1'; } } }, fnInitComplete: function() { if (swh.webapp.isUserLoggedIn()) { $('div.user-requests-filter').html(userRequestsFilterCheckbox); $(`#${addForgeCheckboxId}`).on('change', () => { requestBrowseTable.draw(); }); } }, columns: [ { data: 'submission_date', name: 'submission_date', render: getHumanReadableDate }, { data: 'forge_type', name: 'forge_type' }, { data: 'forge_url', name: 'forge_url' }, { data: 'status', name: 'status', render: function(data, type, row, meta) { return swh.add_forge.formatRequestStatusName(data); } } ] }); } diff --git a/cypress/integration/add-forge-now-request-create.spec.js b/cypress/integration/add-forge-now-request-create.spec.js index d9612f4e..22779994 100644 --- a/cypress/integration/add-forge-now-request-create.spec.js +++ b/cypress/integration/add-forge-now-request-create.spec.js @@ -1,255 +1,262 @@ /** * Copyright (C) 2022 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ function populateForm(type, url, contact, email, consent, comment) { cy.get('#swh-input-forge-type').select(type); cy.get('#swh-input-forge-url').clear().type(url, {delay: 0, force: true}); cy.get('#swh-input-forge-contact-name').clear().type(contact, {delay: 0, force: true}); cy.get('#swh-input-forge-contact-email').clear().type(email, {delay: 0, force: true}); if (comment) { cy.get('#swh-input-forge-comment').clear().type(comment, {delay: 0, force: true}); } cy.get('#swh-input-consent-check').click({force: consent === 'on'}); } describe('Browse requests list tests', function() { beforeEach(function() { - this.addForgeNowUrl = this.Urls.forge_add(); + this.addForgeNowUrl = this.Urls.forge_add_create(); this.listAddForgeRequestsUrl = this.Urls.add_forge_request_list_datatables(); }); it('should not show user requests filter checkbox for anonymous users', function() { cy.visit(this.addForgeNowUrl); cy.get('#swh-add-forge-requests-list-tab').click(); cy.get('#swh-add-forge-user-filter').should('not.exist'); }); it('should show user requests filter checkbox for authenticated users', function() { cy.userLogin(); cy.visit(this.addForgeNowUrl); cy.get('#swh-add-forge-requests-list-tab').click(); cy.get('#swh-add-forge-user-filter').should('exist').should('be.checked'); }); it('should only display user requests when filter is activated', function() { // Clean up previous state cy.task('db:add_forge_now:delete'); // 'user2' logs in and create requests cy.user2Login(); cy.visit(this.addForgeNowUrl); // create requests for the user 'user' populateForm('gitlab', 'gitlab.org', 'admin', 'admin@example.org', 'on', ''); cy.get('#requestCreateForm').submit(); // user requests filter checkbox should be in the DOM cy.get('#swh-add-forge-requests-list-tab').click(); cy.get('#swh-add-forge-user-filter').should('exist').should('be.checked'); // check unfiltered user requests cy.get('tbody tr').then(rows => { expect(rows.length).to.eq(1); }); // user1 logout cy.contains('a', 'logout').click(); // user logs in cy.userLogin(); cy.visit(this.addForgeNowUrl); populateForm('gitea', 'gitea.org', 'admin', 'admin@example.org', 'on', ''); cy.get('#requestCreateForm').submit(); populateForm('cgit', 'cgit.org', 'admin', 'admin@example.org', 'on', ''); cy.get('#requestCreateForm').submit(); // user requests filter checkbox should be in the DOM cy.get('#swh-add-forge-requests-list-tab').click(); cy.get('#swh-add-forge-user-filter').should('exist').should('be.checked'); + // Uncheck and re-check again, to synchronize table state with the checkbox + // FIXME: this should not be needed + cy.get('#swh-add-forge-user-filter').click().click(); + // check unfiltered user requests cy.get('tbody tr').then(rows => { expect(rows.length).to.eq(2); }); cy.get('#swh-add-forge-user-filter') .uncheck({force: true}); // Users now sees everything cy.get('tbody tr').then(rows => { expect(rows.length).to.eq(2 + 1); }); }); }); describe('Test add-forge-request creation', function() { beforeEach(function() { - this.addForgeNowUrl = this.Urls.forge_add(); + this.addForgeNowUrl = this.Urls.forge_add_create(); }); it('should show all the tabs for every user', function() { cy.visit(this.addForgeNowUrl); cy.get('#swh-add-forge-tab') .should('have.class', 'nav-link'); cy.get('#swh-add-forge-requests-list-tab') .should('have.class', 'nav-link'); cy.get('#swh-add-forge-requests-help-tab') .should('have.class', 'nav-link'); }); it('should show create forge tab by default', function() { cy.visit(this.addForgeNowUrl); cy.get('#swh-add-forge-tab') .should('have.class', 'active'); cy.get('#swh-add-forge-requests-list-tab') .should('not.have.class', 'active'); }); it('should show login link for anonymous user', function() { cy.visit(this.addForgeNowUrl); cy.get('#loginLink') .should('be.visible') .should('contain', 'log in'); }); it('should bring back after login', function() { cy.visit(this.addForgeNowUrl); cy.get('#loginLink') .should('have.attr', 'href') - .and('include', `${this.Urls.login()}?next=${this.Urls.forge_add()}`); + .and('include', `${this.Urls.login()}?next=${this.Urls.forge_add_create()}`); }); it('should change tabs on click', function() { cy.visit(this.addForgeNowUrl); cy.get('#swh-add-forge-requests-list-tab').click(); cy.get('#swh-add-forge-tab') .should('not.have.class', 'active'); cy.get('#swh-add-forge-requests-list-tab') .should('have.class', 'active'); cy.get('#swh-add-forge-requests-help-tab') .should('not.have.class', 'active'); - cy.hash().should('eq', '#browse-requests'); + cy.url() + .should('include', `${this.Urls.forge_add_list()}`); cy.get('#swh-add-forge-requests-help-tab').click(); cy.get('#swh-add-forge-tab') .should('not.have.class', 'active'); cy.get('#swh-add-forge-requests-list-tab') .should('not.have.class', 'active'); cy.get('#swh-add-forge-requests-help-tab') .should('have.class', 'active'); - cy.hash().should('eq', '#help'); + cy.url() + .should('include', `${this.Urls.forge_add_help()}`); cy.get('#swh-add-forge-tab').click(); cy.get('#swh-add-forge-tab') .should('have.class', 'active'); cy.get('#swh-add-forge-requests-list-tab') .should('not.have.class', 'active'); cy.get('#swh-add-forge-requests-help-tab') .should('not.have.class', 'active'); - cy.hash().should('eq', ''); + cy.url() + .should('include', `${this.Urls.forge_add_create()}`); }); it('should show create form elements to authenticated user', function() { cy.userLogin(); cy.visit(this.addForgeNowUrl); cy.get('#swh-input-forge-type') .should('be.visible'); cy.get('#swh-input-forge-url') .should('be.visible'); cy.get('#swh-input-forge-contact-name') .should('be.visible'); cy.get('#swh-input-consent-check') .should('be.visible'); cy.get('#swh-input-forge-comment') .should('be.visible'); cy.get('#swh-input-form-submit') .should('be.visible'); }); it('should show browse requests table for every user', function() { // testing only for anonymous cy.visit(this.addForgeNowUrl); cy.get('#swh-add-forge-requests-list-tab').click(); cy.get('#add-forge-request-browse') .should('be.visible'); cy.get('#loginLink') - .should('not.be.visible'); + .should('not.exist'); }); it('should update browse list on successful submission', function() { cy.userLogin(); cy.visit(this.addForgeNowUrl); populateForm('bitbucket', 'gitlab.com', 'test', 'test@example.com', 'on', 'test comment'); cy.get('#requestCreateForm').submit(); cy.visit(this.addForgeNowUrl); cy.get('#swh-add-forge-requests-list-tab').click(); cy.get('#add-forge-request-browse') .should('be.visible') .should('contain', 'gitlab.com'); cy.get('#add-forge-request-browse') .should('be.visible') .should('contain', 'Pending'); }); it('should show error message on conflict', function() { cy.userLogin(); cy.visit(this.addForgeNowUrl); populateForm('bitbucket', 'gitlab.com', 'test', 'test@example.com', 'on', 'test comment'); cy.get('#requestCreateForm').submit(); cy.get('#requestCreateForm').submit(); // Submitting the same data again cy.get('#userMessage') .should('have.class', 'badge-danger') .should('contain', 'already exists'); }); it('should show error message', function() { cy.userLogin(); cy.intercept('POST', `${this.Urls.api_1_add_forge_request_create()}**`, { body: { 'exception': 'BadInputExc', 'reason': '{"add-forge-comment": ["This field is required"]}' }, statusCode: 400 }).as('errorRequest'); cy.visit(this.addForgeNowUrl); populateForm( 'bitbucket', 'gitlab.com', 'test', 'test@example.com', 'off', 'comment' ); cy.get('#requestCreateForm').submit(); cy.wait('@errorRequest').then((xhr) => { cy.get('#userMessage') .should('have.class', 'badge-danger') .should('contain', 'field is required'); }); }); }); diff --git a/swh/web/add_forge_now/views.py b/swh/web/add_forge_now/views.py index 95715140..1d9a2f07 100644 --- a/swh/web/add_forge_now/views.py +++ b/swh/web/add_forge_now/views.py @@ -1,107 +1,127 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from typing import Any, Dict, List from django.conf.urls import url from django.core.paginator import Paginator from django.db.models import Q from django.http.request import HttpRequest from django.http.response import HttpResponse, JsonResponse from django.shortcuts import render from swh.web.add_forge_now.models import Request as AddForgeRequest from swh.web.api.views.add_forge_now import ( AddForgeNowRequestPublicSerializer, AddForgeNowRequestSerializer, ) from swh.web.common.utils import has_add_forge_now_permission def add_forge_request_list_datatables(request: HttpRequest) -> HttpResponse: """Dedicated endpoint used by datatables to display the add-forge requests in the Web UI. """ draw = int(request.GET.get("draw", 0)) add_forge_requests = AddForgeRequest.objects.all() table_data: Dict[str, Any] = { "recordsTotal": add_forge_requests.count(), "draw": draw, } search_value = request.GET.get("search[value]") column_order = request.GET.get("order[0][column]") field_order = request.GET.get(f"columns[{column_order}][name]", "id") order_dir = request.GET.get("order[0][dir]", "desc") if field_order: if order_dir == "desc": field_order = "-" + field_order add_forge_requests = add_forge_requests.order_by(field_order) per_page = int(request.GET.get("length", 10)) page_num = int(request.GET.get("start", 0)) // per_page + 1 if search_value: add_forge_requests = add_forge_requests.filter( Q(forge_type__icontains=search_value) | Q(forge_url__icontains=search_value) | Q(status__icontains=search_value) ) if ( int(request.GET.get("user_requests_only", "0")) and request.user.is_authenticated ): add_forge_requests = add_forge_requests.filter( submitter_name=request.user.username ) paginator = Paginator(add_forge_requests, per_page) page = paginator.page(page_num) if has_add_forge_now_permission(request.user): requests = AddForgeNowRequestSerializer(page.object_list, many=True).data else: requests = AddForgeNowRequestPublicSerializer(page.object_list, many=True).data results = [dict(request) for request in requests] table_data["recordsFiltered"] = add_forge_requests.count() table_data["data"] = results return JsonResponse(table_data) FORGE_TYPES: List[str] = [ "bitbucket", "cgit", "gitlab", "gitea", "heptapod", ] -def create_request(request): +def create_request_create(request): """View to create a new 'add_forge_now' request. """ return render( - request, "add_forge_now/create-request.html", {"forge_types": FORGE_TYPES}, + request, + "add_forge_now/create-request-create.html", + {"forge_types": FORGE_TYPES}, ) +def create_request_list(request): + """View to list existing 'add_forge_now' requests. + + """ + + return render(request, "add_forge_now/create-request-list.html",) + + +def create_request_help(request): + """View to explain 'add_forge_now'. + + """ + + return render(request, "add_forge_now/create-request-help.html",) + + urlpatterns = [ url( r"^add-forge/request/list/datatables/$", add_forge_request_list_datatables, name="add-forge-request-list-datatables", ), - url(r"^add-forge/request/create/$", create_request, name="forge-add"), + url(r"^add-forge/request/create/$", create_request_create, name="forge-add-create"), + url(r"^add-forge/request/list/$", create_request_list, name="forge-add-list"), + url(r"^add-forge/request/help/$", create_request_help, name="forge-add-help"), ] diff --git a/swh/web/templates/add_forge_now/create-request-create.html b/swh/web/templates/add_forge_now/create-request-create.html new file mode 100644 index 00000000..661b81b5 --- /dev/null +++ b/swh/web/templates/add_forge_now/create-request-create.html @@ -0,0 +1,116 @@ +{% extends "./create-request.html" %} + +{% comment %} +Copyright (C) 2022 The Software Heritage developers +See the AUTHORS file at the top-level directory of this distribution +License: GNU Affero General Public License version 3, or any later version +See top-level LICENSE file for more information +{% endcomment %} + +{% block tab_content %} +
+ {% if not user.is_authenticated %} +

+

+ You must be logged in to submit an add forge request. Please + log in +

+

+ {% else %} + +
+ {% csrf_token %} +
+
+ + + + Supported forge types in software archive. + +
+ +
+ + + + Remote URL of the forge. + +
+
+ +
+
+ + + + Name of the forge administrator. + +
+ +
+ + + + Email of the forge administrator. The given email address will not be used for any purpose outside the “add forge now” process. + +
+
+ +
+
+ + +
+
+ +
+
+ + + + Optionally, leave a comment to the moderator regarding your request. + +
+
+ +
+
+ +
+
+ +
+
+

+ +

+

+ +

+
+
+
+

+ Once an add-forge-request is submitted, its status can be viewed in + the + submitted requests list. This process involves a moderator approval and + might take a few days to handle (it primarily depends on the response + time from the forge). +

+ {% endif %} +
+{% endblock %} diff --git a/swh/web/templates/add_forge_now/create-request-help.html b/swh/web/templates/add_forge_now/create-request-help.html new file mode 100644 index 00000000..82550f0c --- /dev/null +++ b/swh/web/templates/add_forge_now/create-request-help.html @@ -0,0 +1,89 @@ +{% extends "./create-request.html" %} + +{% comment %} +Copyright (C) 2022 The Software Heritage developers +See the AUTHORS file at the top-level directory of this distribution +License: GNU Affero General Public License version 3, or any later version +See top-level LICENSE file for more information +{% endcomment %} + +{% block tab_content %} +
+

+ For submitting an "Add forge now" request", you have to provide the following details: +

+ +

+ Once submitted, your "add forge" request can be in one + of the following states +

+ +
+{% endblock %} diff --git a/swh/web/templates/add_forge_now/create-request-list.html b/swh/web/templates/add_forge_now/create-request-list.html new file mode 100644 index 00000000..142fa523 --- /dev/null +++ b/swh/web/templates/add_forge_now/create-request-list.html @@ -0,0 +1,24 @@ +{% extends "./create-request.html" %} + +{% comment %} +Copyright (C) 2022 The Software Heritage developers +See the AUTHORS file at the top-level directory of this distribution +License: GNU Affero General Public License version 3, or any later version +See top-level LICENSE file for more information +{% endcomment %} + +{% block tab_content %} +
+ + + + + + + + + +
Submission dateForge typeForge URLStatus
+
+
+{% endblock %} diff --git a/swh/web/templates/add_forge_now/create-request.html b/swh/web/templates/add_forge_now/create-request.html index 1b5eea9d..a6887447 100644 --- a/swh/web/templates/add_forge_now/create-request.html +++ b/swh/web/templates/add_forge_now/create-request.html @@ -1,255 +1,61 @@ {% extends "../layout.html" %} {% comment %} Copyright (C) 2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load render_bundle from webpack_loader %} {% load static %} {% block header %} {% render_bundle 'add_forge' %} {% endblock %} {% block title %} Add forge now – Software Heritage archive {% endblock %} {% block navbar-content %}

Request the addition of a forge into the archive

{% endblock %} {% block content %}

“Add forge now” provides a service for Software Heritage users to save a complete forge in the Software Heritage archive by requesting the addition of the forge URL into the list of regularly visited forges. {% if not user.is_authenticated %}

You can submit an “Add forge now” request only when you are authenticated, please login to submit the request.

{% endif %}

-
- {% if not user.is_authenticated %} -

-

- You must be logged in to submit an add forge request. Please - log in -

-

- {% else %} - -
- {% csrf_token %} -
-
- - - - Supported forge types in software archive. - -
- -
- - - - Remote URL of the forge. - -
-
- -
-
- - - - Name of the forge administrator. - -
- -
- - - - Email of the forge administrator. The given email address will not be used for any purpose outside the “add forge now” process. - -
-
- -
-
- - -
-
- -
-
- - - - Optionally, leave a comment to the moderator regarding your request. - -
-
- -
-
- -
-
- -
-
-

- -

-

- -

-
-
-
-

- Once an add-forge-request is submitted, its status can be viewed in - the - submitted requests list. This process involves a moderator approval and - might take a few days to handle (it primarily depends on the response - time from the forge). -

- {% endif %} -
-
- - - - - - - - - -
Submission dateForge typeForge URLStatus
-
-
-
-

- For submitting an "Add forge now" request", you have to provide the following details: -

-
    -
  • Forge type:Type of the forge you would like to add. - Supported forge types in software heritage currently are: -
      -
    • cgit, for cgit forges
    • -
    • gitea, for gitea forges
    • -
    • gitlab, for gitlab forges
    • -
    • heptapod, for heptapod forges
    • - ... -
    -
  • -
  • Forge url:The URL of the selected forge. This must be unique. -
  • -
  • Forge contact name:Contact name of the forge administrator -
  • -
  • Forge contact email:Contact email of the forge administrator. An email - will be sent to the given address to notify about the request. -
  • -
  • Consent checkbox: This checkbox's purpose is to know whether we can - explicitly mention the user's login within the email sent to the forge. If - not checked, the user's name won't be mentioned in the email at all. -
  • -
  • Comment: (Optionally) For the user to mention something more about - their request to the add-forge-now moderator. -
  • -
-

- Once submitted, your "add forge" request can be in one - of the following states -

-
    -
  • - Pending: - The request was submitted and is waiting for a moderator - to validate -
  • - -
  • - Waiting for feedback: - The request was processed by a moderator and the forge was contacted. -
  • - -
  • - Feedback to handle: - The forge has responded to the request and - there is feedback to handle for the request. -
  • -
  • - Accepted: - The request has been accepted. -
  • - -
  • - Scheduled: - The requested forge listing has been scheduled. -
  • - -
  • - First listing done: - The first listing of the requested forge has been completed -
  • - -
  • - First origin loaded: - The first repositories (or origins) from the requested forge have been loaded and archived. -
  • - -
  • Rejected:The request is invalid. It is rejected by a moderator with an explanation.
  • - -
  • Denied:The forge administrator(s) denied the request to list their forge.
  • - -
  • Suspended:The forge listing is not supported yet.
  • -
-
+ {% block tab_content %} + {% endblock %}
{% endblock %} diff --git a/swh/web/templates/layout.html b/swh/web/templates/layout.html index a5c0529b..d74886ff 100644 --- a/swh/web/templates/layout.html +++ b/swh/web/templates/layout.html @@ -1,315 +1,315 @@ {% comment %} Copyright (C) 2015-2021 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load js_reverse %} {% load static %} {% load render_bundle from webpack_loader %} {% load swh_templatetags %} {% block title %}{% endblock %} {% render_bundle 'vendors' %} {% render_bundle 'webapp' %} {% render_bundle 'guided_tour' %} {{ request.user.is_authenticated|json_script:"swh_user_logged_in" }} {% block header %}{% endblock %} {% if not swh_web_dev and not swh_web_staging %} {% endif %}
{% comment %} {% endcomment %}
{% if swh_web_staging %}
Staging
v{{ swh_web_version }}
{% elif swh_web_dev %}
Development
v{{ swh_web_version|split:"+"|first }}
{% endif %} {% block content %}{% endblock %}
{% include "includes/global-modals.html" %}
back to top